Repository navigation
Conversation
Adds getTransition() helper, fixes Badge's reduce-motion/duration/easing, converts TextInput label float to CSS transitions, and moves SegmentedButtonItem's checkmark spring onto theme tokens.
There was a problem hiding this comment.
🔵 Needs a closer look
It changes core animation mechanics (tokenized CSS transitions + spring adoption) across widely used components like TextInput, which is cross-platform sensitive and should get final human verification.
Pull request overview
This PR continues the MD3 Expressive motion modernization by (1) introducing a shared helper for Reanimated CSS-transition styles derived from theme motion tokens, and (2) migrating/deduping several components to use those tokens and the CSS-transition tier consistently (including adopting a spring token for SegmentedButtonItem).
Changes:
- Added
getTransition(...)insrc/theme/tokens/sys/motion.tsand unit tests validating token lookup + reduce-motion behavior. - Refactored
TextInputlabel/indicator animations from imperative Reanimated timing/shared-values to render-driven CSS transitions, and added regression tests asserting the computed label styles across focus/blur. - Updated
BadgeandCheckboxto use the shared transition helper; updatedSegmentedButtonItemto use a theme spring token viatoRawSpring(...).
File summaries
| File | Description |
|---|---|
| src/theme/tokens/sys/motion.ts | Adds getTransition helper to standardize CSS transition duration/easing/property from motion tokens. |
| src/theme/tests/motion.test.ts | Adds unit coverage for getTransition token lookup, reduce-motion, and easing construction. |
| src/components/TextInput/TextInput.tsx | Removes now-unused animation handler types; consumes updated hook-driven animation state. |
| src/components/TextInput/hooks.ts | Replaces imperative label/indicator animation with boolean-state-driven CSS transition styles; wires reduce-motion via context. |
| src/components/TextInput/constants.ts | Removes timing/easing constants tied to imperative withTiming animation. |
| src/components/SegmentedButtons/SegmentedButtonItem.tsx | Replaces hardcoded spring config with theme spring token via toRawSpring. |
| src/components/Checkbox/Checkbox.tsx | Dedupes CSS transition props through getTransition. |
| src/components/Badge.tsx | Switches Badge opacity transition to tokenized getTransition + useReduceMotion. |
| src/components/tests/TextInput.test.tsx | Adds assertions for label wrapper/text styles across focus/blur in filled + outlined variants. |
Review details
- Files reviewed: 9/9 changed files
- Comments generated: 1
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
There was a problem hiding this comment.
🔵 Needs a closer look
It changes animation mechanisms and motion-token wiring in core components (notably TextInput), where subtle platform/runtime differences are hard to fully validate from diff review alone.
Review details
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
src/components/tests/TextInput.test.tsx:83
getLabelWrappercurrently returns the first node witharia-hidden === true, butTextInputcan render multiplearia-hiddenelements (e.g. the error icon wrapper). That makes this helper non-specific and can cause future tests to assert styles on the wrong element if anotheraria-hiddennode appears earlier in the tree.
Consider narrowing the predicate to only match the label wrapper by additionally requiring a transition style marker (e.g. transitionProperty in the style object/array), which should be unique to the animated label wrapper in these tests.
- Files reviewed: 9/9 changed files
- Comments generated: 0 new
- Review effort level: Lite
| transitionDuration: 150 * scale, | ||
| transitionProperty: 'opacity', | ||
| ...getTransition(theme, 'opacity', 'short3', 'standard', reduceMotion), | ||
| }; |
There was a problem hiding this comment.
Have you considered typing this style as CSSStyle<ViewStyle> like in Checkbox.tsx?
There was a problem hiding this comment.
Good call, done! Badge renders Animated.Text, so I've updated it to CSSStyle<TextStyle>.
There was a problem hiding this comment.
🔵 Needs a closer look
The helper’s default generic currently permits invalid transition-property names.
Review details
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
src/theme/tokens/sys/motion.ts:142
- The
Record<string, unknown>default erasestransitionPropertyvalidation because its keys accept any string, so typos such as'opactiy'compile and produce a no-op/invalid transition at runtime. Use Reanimated's own defaultCSSTransitionPropertiesstyle type here so callers are constrained to supported style keys.
- Files reviewed: 9/9 changed files
- Comments generated: 0 new
- Review effort level: Balanced
JKobrynski
left a comment
There was a problem hiding this comment.
Just one thing from me
|
|
||
| const checkSpringConfig = React.useMemo( | ||
| () => ({ | ||
| ...toRawSpring(theme.motion.spring.slow.spatial), |
There was a problem hiding this comment.
Should the checkmark spring be fast.spatial, like Switch and FAB? M3 maps fast to small components, slow to full-screen.
- SegmentedButtonItem checkmark spring now uses fast.spatial (small component) instead of slow.spatial, matching Switch/FAB precedent - getTransition's generic no longer erases transitionProperty validation; defaults to a real style union and Badge/Checkbox now specify their own concrete style type - TextInput.test.tsx's getLabelWrapper now also requires a transitionProperty marker (via getAnimatedStyle) so it can't match an unrelated aria-hidden node
|
Found potential problems with the pull request:
|
| theme: { motion: MotionConfig }, | ||
| property: NonNullable<CSSTransitionProperties<S>['transitionProperty']>, | ||
| durationToken: keyof MotionDuration, | ||
| easingToken: keyof MotionEasing, | ||
| reduceMotion: boolean | ||
| ): Required<Pick<CSSTransitionProperties<S>, 'transitionProperty'>> & | ||
| CSSTransitionProperties<S> { | ||
| return { | ||
| transitionProperty: property, | ||
| transitionDuration: reduceMotion ? 0 : theme.motion.duration[durationToken], |
There was a problem hiding this comment.
getTransition ignores theme.animation.scale. On main, Badge used 150 * scale, so this is a regression for Badge. A theme with animation: { scale: 0 } (often used to turn off animations in tests and screenshots) still fades the Badge for 150 ms now, and scale: 2 no longer slows it down. Surface, Chip and Button still multiply their durations by theme.animation.scale, so helper users now behave differently from those components.
Multiply the token by the scale and keep the reduceMotion check (a component-level theme prop can override the scale that PaperProvider sets for reduced motion):
| theme: { motion: MotionConfig }, | |
| property: NonNullable<CSSTransitionProperties<S>['transitionProperty']>, | |
| durationToken: keyof MotionDuration, | |
| easingToken: keyof MotionEasing, | |
| reduceMotion: boolean | |
| ): Required<Pick<CSSTransitionProperties<S>, 'transitionProperty'>> & | |
| CSSTransitionProperties<S> { | |
| return { | |
| transitionProperty: property, | |
| transitionDuration: reduceMotion ? 0 : theme.motion.duration[durationToken], | |
| theme: { motion: MotionConfig; animation: { scale: number } }, | |
| property: NonNullable<CSSTransitionProperties<S>['transitionProperty']>, | |
| durationToken: keyof MotionDuration, | |
| easingToken: keyof MotionEasing, | |
| reduceMotion: boolean | |
| ): Required<Pick<CSSTransitionProperties<S>, 'transitionProperty'>> & | |
| CSSTransitionProperties<S> { | |
| return { | |
| transitionProperty: property, | |
| transitionDuration: reduceMotion | |
| ? 0 | |
| : theme.motion.duration[durationToken] * theme.animation.scale, |
motion.test.ts then needs animation: { scale: 1 } in its theme, plus a test that a scale of 0 gives a duration of 0. (via Claude, verify if it makes sense)
| * Zeroes the duration when `reduceMotion` is set, per the CSS tier's | ||
| * reduce-motion contract (a Reanimated `ReduceMotion` option doesn't apply | ||
| * to CSS transitions). |
There was a problem hiding this comment.
The repo does not define a "CSS tier" or a "reduce-motion contract". The same goes for "the motion decision rule" in src/components/TextInput/hooks.ts line 32. A reader cannot look these up. The documented rule is the useReduceMotion JSDoc in src/theme/accessibility/ReduceMotionContext.tsx, and it says to keep opacity and color animations when reduced motion is on. This helper zeroes every property, opacity included (Badge, the Checkbox fill, the TextInput input fade). Either follow that rule or update the useReduceMotion doc. Then describe the behavior in plain words here and in hooks.ts, for example "Sets the duration to 0 when reduceMotion is true, because Reanimated's ReduceMotion option has no effect on CSS transitions." (via Claude, verify if it makes sense)
| reduceMotion: reduceMotion ? ReduceMotion.Always : ReduceMotion.Never, | ||
| }); | ||
| }, [checked, checkScale, reduceMotion, showSelectedCheck]); | ||
| checkScale.value = withSpring(checked ? 1 : 0, checkSpringConfig); |
There was a problem hiding this comment.
The check animation runs on a shared value with withSpring, but it is driven by the checked prop and not by a gesture or scroll. The project rule is to use Reanimated CSS transitions (transitionProperty and transitionDuration, as in Badge.tsx and Checkbox.tsx) for state changes, and to keep shared values for gesture or scroll driven motion. Please move the check scale and the icon scale to CSS transitions on transform. Reanimated 4.3 exports linear(...), so you can keep the fast.spatial spring shape by sampling it into linear() points for transitionTimingFunction. With that change, the useMemo and the effect go away. (via Claude, verify if it makes sense)

Motivation
MD3 Expressive replaces easing+duration with springs where motion is spring-shaped; CSS transitions are the default otherwise. #5015 handled the library swap + CSS-transition conversion; this PR adds spring adoption + a shared
getTransitionhelper.getTransition()CSS-transition helper intheme/tokens/sys/motion.tsBadge: uses helper, fixes duration/easing/reduce-motionCheckbox: deduped onto helper, no behavior changeTextInput: label float/active-border now CSS transitionsSegmentedButtonItem: checkmark spring nowfast.spatialtoken, matchingSwitch/FABFollow-up (separate issue): some #5015 files still use imperative
withTimingfor non-spring motion, out of scope here.Related issue
Part of the MD3 motion modernization; no tracking issue yet.
Test plan
yarn typecheck/lint/jestpass (686 tests), incl. newgetTransition+ TextInput focus/blur tests. Manual verification + screenshots below.Screenshots
Badge
Simulator.Screen.Recording.-.iPhone.17.Pro.-.2026-09-08.at.13.08.16.mov
Checkbox / Checkbox Item
Simulator.Screen.Recording.-.iPhone.17.Pro.-.2026-09-08.at.13.10.19.mov
TextInput
Simulator.Screen.Recording.-.iPhone.17.Pro.-.2026-09-08.at.12.56.43.mov
Segmented Buttons
Simulator.Screen.Recording.-.iPhone.17.Pro.-.2026-09-08.at.12.58.39.mov